package net.apexes.commons.lang;

import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * @author hedyn
 */
class NetworkTimeMillis {
    private NetworkTimeMillis() {}

    private static final Logger LOG = Logger.getLogger(NetworkTimeMillis.class.getName());

    static long invoke(int timeoutMs, InetSocketAddress... addrs)
            throws NetworkTimeMillisException, InterruptedException, ExecutionException, TimeoutException {
        List<InetSocketAddress> addressList = new ArrayList<>();
        for (InetSocketAddress addr : addrs) {
            if (addr != null && addr.getAddress() != null) {
                addressList.add(addr);
            }
        }
        if (addressList.isEmpty()) {
            throw new NetworkTimeMillisException("Unknown host.");
        }
        ExecutorService executor = Executors.newFixedThreadPool(addressList.size());
        try {
            return invoke(executor, timeoutMs, addressList);
        } finally {
            executor.shutdownNow();
        }
    }

    static long invoke(ExecutorService executor, int timeoutMs, InetSocketAddress... addrs)
            throws NetworkTimeMillisException, InterruptedException, ExecutionException, TimeoutException {
        List<InetSocketAddress> addressList = new ArrayList<>();
        for (InetSocketAddress addr : addrs) {
            if (addr != null && addr.getAddress() != null) {
                addressList.add(addr);
            }
        }
        if (addressList.isEmpty()) {
            throw new NetworkTimeMillisException("Unknown host.");
        }
        return invoke(executor, timeoutMs, addressList);
    }

    private static long invoke(ExecutorService executor, int timeoutMs, List<InetSocketAddress> addrs)
            throws NetworkTimeMillisException, InterruptedException, ExecutionException, TimeoutException {
        Checks.verifyNotNull(executor, "executor");
        Checks.verifyNotEmpty(addrs, "addrs");

        List<Result> resultList = new ArrayList<>();
        List<NetworkTimeMillisTask> taskList = new ArrayList<>();
        for (InetSocketAddress addr : addrs) {
            taskList.add(new NetworkTimeMillisTask(addr, timeoutMs));
        }
        long timeout;
        if (timeoutMs > 0) {
            timeout = timeoutMs * 2L + 500;
        } else {
            timeout = timeoutMs;
        }
        List<Future<Result>> futureList = executor.invokeAll(taskList);
        for (Future<Result> future : futureList) {
            resultList.add(future.get(timeout, TimeUnit.MILLISECONDS));
        }

        Map<InetSocketAddress, Long> timeMap = new LinkedHashMap<>();
        Map<InetSocketAddress, Throwable> causeMap = new LinkedHashMap<>();
        for (Result result : resultList) {
            if (result.exception != null) {
                causeMap.put(result.addr, result.exception);
            } else if (result.timeMillis != 0) {
                timeMap.put(result.addr, result.timeMillis());
            }
        }

        if (addrs.size() == 1) {
            if (timeMap.isEmpty()) {
                throw new NetworkTimeMillisException(timeMap, causeMap);
            }
            return timeMap.get(addrs.get(0));
        }

        if (LOG.isLoggable(Level.INFO)) {
            LOG.log(Level.INFO, "networkTimeMillis: {0}", timeMap);
        }

        if (timeMap.size() >= 2) {
            List<Long> timeList = new ArrayList<>(timeMap.values());
            Long minDiff = null;
            Long select1 = null;
            Long select2 = null;
            for (int i = timeList.size() - 1; i > 0; i--) {
                for (int j = i - 1; j >= 0; j--) {
                    long value1 = timeList.get(i);
                    long value2 = timeList.get(j);
                    long diff = Math.abs(value1 - value2);
                    if (diff == 0) {
                        return value1;
                    }
                    if (minDiff == null || minDiff > diff) {
                        minDiff = diff;
                        select1 = value1;
                        select2 = value2;
                    }
                }
            }
            if (minDiff != null && minDiff < 30000) {
                return Math.max(select1, select2);
            }
        }

        throw new NetworkTimeMillisException(timeMap, causeMap);
    }

    /**
     *
     * @author <a href=mailto:hedyn@foxmail.com>HeDYn</a>
     */
    private static class NetworkTimeMillisTask implements Callable<Result> {

        private final InetSocketAddress addr;
        private final int timeoutMs;

        private NetworkTimeMillisTask(InetSocketAddress addr, int timeoutMs) {
            this.addr = addr;
            this.timeoutMs = timeoutMs;
        }

        @Override
        public Result call() {
            try {
                long timeMillis = NTPTimeMillis.requestTime(addr.getAddress(), addr.getPort(), timeoutMs);
                return new Result(addr, timeMillis);
            } catch (Exception e) {
                return new Result(addr, e);
            }
        }
    }

    private static class Result {

        private final InetSocketAddress addr;
        private final Long timeMillis;
        private final long nanoTime;
        private final Exception exception;

        private Result(InetSocketAddress addr, long timeMillis) {
            this.addr = addr;
            this.timeMillis = timeMillis;
            this.nanoTime = System.nanoTime();
            this.exception = null;
        }

        private Result(InetSocketAddress addr, Exception exception) {
            this.addr = addr;
            this.timeMillis = null;
            this.nanoTime = 0;
            this.exception = exception;
        }

        long timeMillis() {
            return timeMillis + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nanoTime);
        }
    }
}
